Improved Animated Scrolling Script for Same-Page Links

Course- Javascript >

After posting the last entry on animated scrolling with jQuery 1.2, I realized that I had left out an important piece of code. Actually, I didn't discover it until someone notified me that another page on the site was broken. Can you spot the problem(s)? [Note: the problem is not in line 3. The syntax highlighter just can't handle the regular expression with two slashes in it ("//") and is incorrectly treating them as a comment mark.] See the answer below the code.

JavaScript:
  1. $(document).ready(function(){
  2.   $('a[href*=#]').click(function() {
  3.     if (location.pathname.replace(/^\//,'') == this.pathname.replace(/^\//,'')
  4.     && location.hostname == this.hostname) {
  5.       var $target = $(this.hash);
  6.       $target = $target.length && $target
  7.       || $('[name=' + this.hash.slice(1) +']');
  8.       if ($target.length) {
  9.         var targetOffset = $target.offset().top;
  10.         $('html,body')
  11.         .animate({scrollTop: targetOffset}, 1000);
  12.        return false;
  13.       }
  14.     }
  15.   });
  16. });


Answer: The animated scrolling script hijacks links that look like this: <a href="#">. A couple people confirmed in the comments that the script needed a bit more work, so I figured we could take one more pass at it.

By the way, even though we attached the click event handler to all links that have the "#" symbol anywhere in the href, the very next line ensures that the link is pointing to the same page — by checking for a match between location.pathname and this.pathname — and the line after that ensures that it's pointing to the same domain, by checking for a match between location.hostname and this.hostname. With this approach, we can accommodate same-page links whether they include a fully-qualified URL, a relative URL, or just the fragment identifier.

Check for the Hash

Let's fix the problem with the <a href="#"> links. The first thing we have to do is see if there is actually something following the "#" symbol in the href. Apparently, if there is a lone "#" symbol, without any following characters, Firefox and Internet Explorer don't consider it a hash. Safari does, however. So, to avoid a false positive on <a href="#">, we need to first strip the "#" and then check if there is anything left. We can do so by adding this condition to the first if statement: && this.hash.replace(/#/,'')

Check for the Named Anchor

Since we're already changing the script, maybe it's a good time to make some of it more readable, too. This part with the "short-circuit" logic, using && and ||, makes me a little dizzy:

JavaScript:
  1. var $target = $(this.hash);
  2. $target = $target.length && $target
  3. || $('[name=' + this.hash.slice(1) +']');
  4. if ($target.length) {

 

There is absolutely nothing wrong with this syntax. In fact, more advanced JavaScripters use it all the time. But I feel more comfortable using a simpler, more straightforward style. So, let's set two variables — one for a target ID and one for a target named anchor. We'll then use conditional (aka ternary) operators to set a third, $target, variable as the target ID if it's there, and if not, the target named anchor if it's there, and if not, false. Then we can just check if $target has some value (other than false):

JavaScript:
  1. var $targetId = $(this.hash),
  2.   $targetAnchor = $('[name=' + this.hash.slice(1) +']');
  3. var $target = $targetId.length ? $targetId
  4.   : $targetAnchor.length ? $targetAnchor
  5.     : false;
  6. if ($target) {

 

Now it appears that the animated scrolling behavior will be attached to all same-page links and not break other stuff on the page.

Loop First, Bind Last

But there is another problem. Since we're still binding the .click() method to every link with "#" in it, even if it's appropriately avoiding applying the animation for some of those links, jQuery is still hijacking links that have an inline onclick handler (but, oddly, only the first time those links are clicked). To fix this problem, we can replace the .click() with .each(). Then we'll iterate through all links that have "#" somewhere in them, but place the conditions inside the loop so that we bind the click handler only after we've filtered out all the links that don't apply. Here is what the script looks like with the change:

JavaScript:
  1. $(document).ready(function() {
  2.   $('a[href*=#]').each(function() {
  3.     if (location.pathname.replace(/^\//,'') == this.pathname.replace(/^\//,'')
  4.     && location.hostname == this.hostname
  5.     && this.hash.replace(/#/,'') ) {
  6.       var $targetId = $(this.hash), $targetAnchor = $('[name=' + this.hash.slice(1) +']');
  7.       var $target = $targetId.length ? $targetId : $targetAnchor.length ? $targetAnchor : false;
  8.        if ($target) {
  9.          var targetOffset = $target.offset().top;
  10.          $(this).click(function() {
  11.            $('html, body').animate({scrollTop: targetOffset}, 400);
  12.            return false;
  13.          });
  14.       }
  15.     }
  16.   });
  17. });

 

Notice especially lines 2 and line 10. This change not only takes care of our problem, but it feels cleaner somehow, too. Is it more efficient? I don't know. Maybe someone else can tell us in the comments.

Normalize Directory Indexes

To be complete, we should probably take care of one more thing: the possibility that, on an "index" page, a link could point to "/path/index.htm" when the current location says "/path/" or vice versa. One way to "normalize" these index pages and links is to add a couple more .replace() methods to both sides of the equation in line 3.

Update

Aman suggested in a comment below that I make this process DRYer, and kangax provided a great example. So we can write a filter function and apply it to both sides rather than repeating the three replaces on each side:

JavaScript:
  1. function filterPath(string) {
  2.   return string
  3.     .replace(/^\//,'')  
  4.     .replace(/(index|default).[a-zA-Z]{3,4}$/,'')  // first additional replace
  5.     .replace(/\/$/,'');  // second additional replace
  6. }

 

The first additional .replace() will find a string represented by "index" or "default," followed by a dot, followed by any three or four letters at the end the pathname, and replace it with an empty string (i.e. remove it). The second one will replace a trailing slash with an empty string. As with chained jQuery methods, these regular-expression methods can be placed on separate lines to improve readability. Finally, we have a bullet-proof (I hope) animated scrolling script for same-page links:

JavaScript:
  1. $(document).ready(function() {
  2.   function filterPath(string) {
  3.     return string
  4.       .replace(/^\//,'')  
  5.       .replace(/(index|default).[a-zA-Z]{3,4}$/,'')  
  6.       .replace(/\/$/,'');
  7.   }
  8.   $('a[href*=#]').each(function() {
  9.     if ( filterPath(location.pathname) == filterPath(this.pathname)
  10.     && location.hostname == this.hostname
  11.     && this.hash.replace(/#/,'') ) {
  12.       var $targetId = $(this.hash), $targetAnchor = $('[name=' + this.hash.slice(1) +']');
  13.       var $target = $targetId.length ? $targetId : $targetAnchor.length ? $targetAnchor : false;
  14.        if ($target) {
  15.          var targetOffset = $target.offset().top;
  16.          $(this).click(function() {
  17.            $('html, body').animate({scrollTop: targetOffset}, 400);
  18.            return false;
  19.          });
  20.       }
  21.     }
  22.   });
  23. });

 

If you try it out, let me know how it goes.

Update 2

Ariel Flesler has written an excellent ScrollTo plugin, which he says was inspired by this blog entry. Be sure to check out the demo.

Update 3

Someone called my attention to a problem that this script was having in IE and Opera. Not sure how I could have missed that, because I'm sure I tested it in both of those browsers. But never mind, I've come up with a little patch:

JavaScript:
  1. $(document).ready(function() {
  2.   function filterPath(string) {
  3.   return string
  4.     .replace(/^\//,'')
  5.     .replace(/(index|default).[a-zA-Z]{3,4}$/,'')
  6.     .replace(/\/$/,'');
  7.   }
  8.   var locationPath = filterPath(location.pathname);
  9.   $('a[href*=#]').each(function() {
  10.     var thisPath = filterPath(this.pathname) || locationPath;
  11.     if (  locationPath == thisPath
  12.     && (location.hostname == this.hostname || !this.hostname)
  13.     && this.hash.replace(/#/,'') ) {
  14.       var $target = $(this.hash), target = this.hash;
  15.       if (target) {
  16.         var targetOffset = $target.offset().top;
  17.         $(this).click(function(event) {
  18.           event.preventDefault();
  19.           $('html, body').animate({scrollTop: targetOffset}, 400, function() {
  20.             location.hash = target;
  21.           });
  22.         });
  23.       }
  24.     }
  25.   });
  26. });

 

Apparently, IE doesn't see a hostname or pathname if a link's href attribute is set with JavaScript and contains only a hash (such as "#example"). So, I'm checking now for either a match or an absence of hostname and pathname.

I hope this change fixes the problem that Mike was having. Seems to work in my tests now. Oh, and I took the opportunity to improve the code a bit. Now, it has mild back-button support: while clicking on the back button doesn't produce the animated scrolling, it at least gets you back to the previous location.

Update 4

Ariel Flesler suggested that the problem a few people have mentioned regarding this script with Opera has to do with this line: $('html, body').animate({scrollTop: targetOffset}, 400); Here is a fix:

JavaScript:
  1. $(document).ready(function() {
  2.   function filterPath(string) {
  3.   return string
  4.     .replace(/^\//,'')
  5.     .replace(/(index|default).[a-zA-Z]{3,4}$/,'')
  6.     .replace(/\/$/,'');
  7.   }
  8.   var locationPath = filterPath(location.pathname);
  9.   var scrollElem = scrollableElement('html', 'body');
  10.  
  11.   $('a[href*=#]').each(function() {
  12.     var thisPath = filterPath(this.pathname) || locationPath;
  13.     if (  locationPath == thisPath
  14.     && (location.hostname == this.hostname || !this.hostname)
  15.     && this.hash.replace(/#/,'') ) {
  16.       var $target = $(this.hash), target = this.hash;
  17.       if (target) {
  18.         var targetOffset = $target.offset().top;
  19.         $(this).click(function(event) {
  20.           event.preventDefault();
  21.           $(scrollElem).animate({scrollTop: targetOffset}, 400, function() {
  22.             location.hash = target;
  23.           });
  24.         });
  25.       }
  26.     }
  27.   });
  28.  
  29.   // use the first element that is "scrollable"
  30.   function scrollableElement(els) {
  31.     for (var i = 0, argLength = arguments.length; i <argLength; i++) {
  32.       var el = arguments[i],
  33.           $scrollElement = $(el);
  34.       if ($scrollElement.scrollTop()> 0) {
  35.         return el;
  36.       } else